spb/cancerindex
Public
TypeScript 97.2%
SQL 1.5%
CSS 0.6%
JavaScript 0.5%
1import type { Metadata } from 'next';2import Link from 'next/link';3import { notFound } from 'next/navigation';4import { PageHeader, Section, KV, Note } from '@/components/ui/section';5import { EmptyState } from '@/components/ui/empty-state';6import { Freshness } from '@/components/ui/freshness';7import { Badge, ClaimBadge, ConfidenceBadge } from '@/components/ui/badge';8import { SourceBadge } from '@/components/ui/source-badge';9import { Breadcrumbs } from '@/components/layout/breadcrumbs';10import { CountryActivity } from '@/components/country/activity';11import { TrendChart, type TrendSeries } from '@/components/charts/trend-chart';12import { getGeographyBySlug, coverageFor, yearsFor, allSitesObservations, topCancersFor, trendFor, geographyScopeCode, WHO_REGION_LABEL, SEXES, BURDEN_METRICS, type Sex, type TopCancersResult } from '@/lib/queries/geography';13import { EPI_METRIC_LABEL } from '@/lib/queries/epidemiology';14import { loadProvenance, toInfo } from '@/lib/queries/provenance';15import { listSources } from '@/lib/queries/sources';16import { jsonLd } from '@/lib/seo';17import { SITE_URL, SITE_NAME } from '@/lib/site';18import { str, int, oneOf, type SP } from '@/lib/search-params';19import { fmtInt, fmtValue, humanize, toDate, unitLabel } from '@/lib/format';2021export const revalidate = 3600;2223type Params = { slug: string };2425export async function generateMetadata({ params }: { params: Promise<Params> }): Promise<Metadata> {26 const geo = await getGeographyBySlug((await params).slug);27 if (!geo) return { title: 'Not found' };28 return {29 title: `${geo.name} — cancer statistics`,30 description: `Cancer incidence and mortality observations for ${geo.name}: top cancers by annual deaths, new cases and age-standardized rates per year and sex, with trends and sources.`,31 alternates: { canonical: `/country/${geo.slug}` },32 };33}3435/**36 * /country/[slug] (§47): header, latest-year summary, top-cancer tables per metric with sex/year selectors,37 * a multi-line trend of the top 8 cancers' age-standardized mortality, and a sources/freshness block.38 * Every value comes from epidemiology_observations; ranks from the matching ranking snapshot when it exists.39 */40export default async function CountryPage({ params, searchParams }: { params: Promise<Params>; searchParams: Promise<SP> }) {41 const { slug } = await params;42 const sp = await searchParams;43 const geo = await getGeographyBySlug(slug);44 if (!geo) notFound();45 const [coverage, years, sources] = await Promise.all([coverageFor(geo.id), yearsFor(geo.id), listSources()]);46 const latestYear = years[0];47 const sex = oneOf<Sex>(sp, 'sex', SEXES, 'all');48 const year = latestYear ? int(sp, 'year', latestYear, years[years.length - 1]!, latestYear) : null;49 const scopeCode = geographyScopeCode(geo);5051 const crumbs = [{ label: 'Countries', href: '/countries' }, ...(geo.parent_slug && geo.parent_name && geo.kind === 'subdivision' ? [{ label: geo.parent_name, href: `/country/${geo.parent_slug}` }] : []), { label: geo.name }];5253 if (!latestYear || year == null) {54 return (55 <div>56 <Breadcrumbs items={crumbs} className="pt-5" />57 <PageHeader kicker={humanize(geo.kind)} title={geo.name} lede={headerLede(geo)} />58 <EmptyState title="No epidemiology observation for this geography yet">59 Country statistics appear once a licensed registry connector has ingested observations for {geo.name}. IARC / GLOBOCAN (185 countries) is under license review and the SEER API awaits credentials.60 <div className="mt-1">61 <Link className="ci-link" href="/countries">62 Geographies with data63 </Link>64 </div>65 </EmptyState>66 <div className="mt-8 space-y-8">67 <CountryActivity iso2={geo.iso2} iso3={geo.iso3} name={geo.name} kind={geo.kind} />68 </div>69 </div>70 );71 }7273 const [allSites, ...tops] = await Promise.all([allSitesObservations(geo.id, year, sex), ...BURDEN_METRICS.map((m) => topCancersFor(geo, m, year, sex, 40))]);74 const byMetric = new Map(tops.map((t) => [t.metric, t]));75 const asmr = byMetric.get('as_mortality_rate');76 const trendIds = (asmr?.rows.length ? asmr : byMetric.get('mortality_count'))?.rows.slice(0, 8).map((r) => r.cancer_id) ?? [];77 const trendMetric = asmr?.rows.length ? 'as_mortality_rate' : 'mortality_count';78 const trend = await trendFor(geo.id, trendMetric, sex, trendIds);79 const provIds = tops.flatMap((t) => t.rows.slice(0, 1).map((r) => r.provenance_id));80 const prov = await loadProvenance([...provIds, ...allSites.map((a) => a.provenance_id)]);8182 const usedSources = [...new Set(coverage.map((c) => c.source_slug))].map((s) => sources.find((x) => x.slug === s)).filter((s): s is NonNullable<typeof s> => !!s);83 const freshest = coverage.map((c) => toDate(c.last_updated)).filter((d): d is Date => !!d).sort((a, b) => b.getTime() - a.getTime())[0] ?? null;84 const nCancers = Math.max(...coverage.map((c) => c.n_cancers), 0);85 const estimateTypes = [...new Set(coverage.flatMap((c) => c.estimate_types))];86 const standardPop = coverage.find((c) => c.standard_population)?.standard_population ?? null;8788 const ld = {89 '@context': 'https://schema.org',90 '@type': 'Dataset',91 name: `Cancer incidence and mortality observations — ${geo.name}`,92 description: `Per-site cancer observations for ${geo.name} (${years[years.length - 1]}–${latestYear}): annual deaths, new cases and age-standardized rates by sex, as published by ${usedSources.map((s) => s.name).join('; ') || 'the source registry'}. Normalized by CancerIndex without changing values.`,93 url: `${SITE_URL}/country/${geo.slug}`,94 creator: { '@type': 'Organization', name: SITE_NAME, url: SITE_URL },95 isBasedOn: usedSources.map((s) => s.homepage ?? `${SITE_URL}/source/${s.slug}`),96 spatialCoverage: { '@type': 'Place', name: geo.name, ...(geo.iso3 ? { identifier: geo.iso3 } : {}) },97 temporalCoverage: `${years[years.length - 1]}/${latestYear}`,98 variableMeasured: [...new Set(coverage.map((c) => EPI_METRIC_LABEL[c.metric] ?? c.metric))],99 license: usedSources.map((s) => s.license).filter(Boolean).join('; ') || undefined,100 ...(freshest ? { dateModified: freshest.toISOString() } : {}),101 };102103 const q = (over: Partial<{ sex: string; year: number }>) => {104 const p = new URLSearchParams();105 const s = over.sex ?? sex;106 const y = over.year ?? year;107 if (s !== 'all') p.set('sex', s);108 if (y !== latestYear) p.set('year', String(y));109 const qs = p.toString();110 return `/country/${geo.slug}${qs ? `?${qs}` : ''}`;111 };112113 return (114 <article>115 <script type="application/ld+json" dangerouslySetInnerHTML={{ __html: jsonLd(ld) }} />116 <Breadcrumbs items={crumbs} className="pt-5" />117 <PageHeader kicker={humanize(geo.kind)} title={geo.name} lede={headerLede(geo)}>118 <KV119 className="mt-3 max-w-xl"120 items={[121 { k: 'ISO 3166-1', v: geo.iso3 ? <span className="ci-mono">{geo.iso3}{geo.iso2 ? ` · ${geo.iso2}` : ''}</span> : null },122 { k: 'WHO region', v: geo.who_region ? WHO_REGION_LABEL[geo.who_region] ?? geo.who_region : null },123 { k: 'Population', v: geo.population ? <span className="ci-num">{fmtInt(geo.population)}{geo.population_year ? ` (${geo.population_year})` : ''}</span> : <span className="text-ink-3">not seeded — CancerIndex shows only rates and counts published by the source</span> },124 { k: 'Years covered', v: <span className="ci-num">{years[years.length - 1]}–{latestYear}</span> },125 { k: 'Cancer entities', v: <span className="ci-num">{fmtInt(nCancers)}</span> },126 { k: 'Estimate types', v: estimateTypes.map((t) => <Badge key={t} tone={t === 'observed' ? 'ok' : 'warn'} className="mr-1">{t}</Badge>) },127 ]}128 />129 </PageHeader>130131 {/* Selectors: sex + year (server-side URL state) */}132 <nav aria-label="Scope" className="flex flex-wrap items-center gap-x-6 gap-y-2 border-y border-rule py-2 text-[13px]">133 <div className="flex items-center gap-1.5">134 <span className="ci-kicker mr-1">Sex</span>135 {SEXES.map((s) => (136 <Link key={s} href={q({ sex: s })} aria-current={s === sex ? 'page' : undefined} className={`border px-2 py-0.5 no-underline ${s === sex ? 'border-accent bg-accent-soft text-accent-2' : 'border-rule text-ink-2 hover:border-accent'}`}>137 {s === 'all' ? 'Both sexes' : humanize(s)}138 </Link>139 ))}140 </div>141 <div className="flex flex-wrap items-center gap-1.5">142 <span className="ci-kicker mr-1">Year</span>143 {years.slice(0, 8).map((y) => (144 <Link key={y} href={q({ year: y })} aria-current={y === year ? 'page' : undefined} className={`ci-num border px-2 py-0.5 no-underline ${y === year ? 'border-accent bg-accent-soft text-accent-2' : 'border-rule text-ink-2 hover:border-accent'}`}>145 {y}146 </Link>147 ))}148 {years.length > 8 ? (149 <details className="relative">150 <summary className="cursor-pointer border border-rule px-2 py-0.5 text-ink-2 hover:border-accent">earlier…</summary>151 <div className="absolute left-0 z-10 mt-1 flex max-w-[320px] flex-wrap gap-1 border border-rule-strong bg-paper p-2 shadow-lg">152 {years.slice(8).map((y) => (153 <Link key={y} href={q({ year: y })} className={`ci-num border px-2 py-0.5 no-underline ${y === year ? 'border-accent bg-accent-soft text-accent-2' : 'border-rule text-ink-2 hover:border-accent'}`}>154 {y}155 </Link>156 ))}157 </div>158 </details>159 ) : null}160 </div>161 </nav>162163 {/* Summary cards */}164 <Section id="summary" kicker="Summary" title={`${year} · ${sex === 'all' ? 'both sexes' : humanize(sex)} · all ages`} description="Totals across all cancer sites are shown only when the source publishes an all-sites row. Otherwise the per-site figures below stand on their own; CancerIndex never sums site groups (definitions overlap between sources).">165 {allSites.length ? (166 <ul className="grid grid-cols-1 gap-px bg-rule sm:grid-cols-2 lg:grid-cols-4">167 {allSites.map((a) => (168 <li key={a.metric} className="bg-paper px-3 py-2.5">169 <span className="text-[11.5px] font-medium uppercase tracking-wide text-ink-3">{EPI_METRIC_LABEL[a.metric] ?? a.metric} · all sites</span>170 <span className="mt-0.5 flex items-baseline gap-1.5">171 <span className="ci-num font-display text-2xl">{fmtValue(a.value, a.unit)}</span>172 <span className="text-[11.5px] text-ink-3">{unitLabel(a.unit)}</span>173 </span>174 <span className="block text-[11.5px] text-ink-3">175 {a.year} · {a.site_definition}176 </span>177 <span className="mt-1 flex items-center gap-1.5">178 <SourceBadge p={toInfo(prov.get(a.provenance_id)) ?? { sourceSlug: a.source_slug, sourceName: a.source_name }} />179 <ClaimBadge kind="observed" />180 {a.estimate_type !== 'observed' ? <Badge tone="warn">{a.estimate_type}</Badge> : null}181 </span>182 </li>183 ))}184 </ul>185 ) : (186 <ul className="grid grid-cols-1 gap-px bg-rule sm:grid-cols-2 lg:grid-cols-4">187 {BURDEN_METRICS.map((m) => {188 const t = byMetric.get(m);189 const top = t?.rows[0];190 return (191 <li key={m} className="bg-paper px-3 py-2.5">192 <span className="text-[11.5px] font-medium uppercase tracking-wide text-ink-3">{EPI_METRIC_LABEL[m] ?? m}</span>193 {top && t ? (194 <>195 <span className="mt-0.5 block text-[12px] text-ink-3">196 highest site group{t.year !== year ? ` (${t.year}, latest available)` : ''}:197 </span>198 <Link href={`/cancer/${top.slug}`} className="ci-link block truncate text-[14px]">199 {top.canonical_name}200 </Link>201 <span className="flex items-baseline gap-1.5">202 <span className="ci-num font-display text-2xl">{fmtValue(top.value, top.unit)}</span>203 <span className="text-[11.5px] text-ink-3">{unitLabel(top.unit)}</span>204 </span>205 <span className="mt-1 flex flex-wrap items-center gap-1.5">206 <SourceBadge p={toInfo(prov.get(top.provenance_id)) ?? { sourceSlug: top.source_slug, sourceName: top.source_name }} />207 <ClaimBadge kind="observed" />208 <span className="text-[11px] text-ink-3">{fmtInt(t.rows.length)} site groups</span>209 </span>210 </>211 ) : (212 <span className="mt-1 block text-[12.5px] text-ink-3">no observation for {year}</span>213 )}214 </li>215 );216 })}217 </ul>218 )}219 {!allSites.length ? <p className="mt-2 text-[12px] text-ink-3">No all-sites total in source for this scope — per-site figures below; no national total is computed.</p> : null}220 </Section>221222 {/* Top tables */}223 {tops.map((t) => (224 <TopTable key={t.metric} t={t} year={year} sex={sex} scopeCode={scopeCode} prov={prov} geoSlug={geo.slug} />225 ))}226227 {/* Trend */}228 <Section id="trend" kicker="Trend" title={`${EPI_METRIC_LABEL[trendMetric]} — top ${Math.min(8, trendIds.length)} cancers, all years`} description={`Top cancers of ${year} (${sex === 'all' ? 'both sexes' : humanize(sex)}) traced over every year published for ${geo.name}. Dashed lines mark estimated or projected values; the y axis starts at zero.`}>229 {trend.length ? (230 <>231 <TrendChart series={toSeries(trend)} unit={trend[0]!.unit} ariaLabel={`${EPI_METRIC_LABEL[trendMetric]} by year for the top ${trendIds.length} cancers in ${geo.name}`} yLabel={unitLabel(trend[0]!.unit)} />232 <p className="mt-1.5 flex flex-wrap items-center gap-1.5 text-[11.5px] text-ink-3">233 <SourceBadge p={{ sourceSlug: trend[0]!.source_slug }} compact />234 <ClaimBadge kind="observed" />235 {trend[0]!.standard_population ? <span>standard: {trend[0]!.standard_population}</span> : null}236 <span>{fmtInt(trend.length)} points</span>237 </p>238 </>239 ) : (240 <EmptyState compact>No multi-year series for this scope.</EmptyState>241 )}242 </Section>243244 <CountryActivity iso2={geo.iso2} iso3={geo.iso3} name={geo.name} kind={geo.kind} />245246 {/* Sources & freshness */}247 <Section id="sources" kicker="Provenance" title="Sources and freshness" description="What each source covers for this geography, exactly as ingested. Values are normalized (units, labels) but never changed.">248 <div className="ci-table-wrap">249 <table className="ci-table">250 <thead>251 <tr>252 <th>Source</th>253 <th>Metric</th>254 <th>Sex</th>255 <th className="num">Years</th>256 <th className="num">Cancers</th>257 <th>Estimate types</th>258 <th>Standard population</th>259 <th>Ingested</th>260 </tr>261 </thead>262 <tbody>263 {coverage.map((c) => (264 <tr key={`${c.source_slug}-${c.metric}-${c.sex}`}>265 <td>266 <SourceBadge p={{ sourceSlug: c.source_slug, sourceName: c.source_name }} compact />267 </td>268 <td>{EPI_METRIC_LABEL[c.metric] ?? c.metric}</td>269 <td>{c.sex === 'all' ? 'Both' : humanize(c.sex)}</td>270 <td className="num">271 {c.min_year}–{c.max_year} <span className="text-ink-3">({c.n_years})</span>272 </td>273 <td className="num">{fmtInt(c.n_cancers)}</td>274 <td>275 {c.estimate_types.map((t) => (276 <Badge key={t} tone={t === 'observed' ? 'ok' : 'warn'} className="mr-1">277 {t}278 </Badge>279 ))}280 </td>281 <td className="text-[12px] text-ink-3">{c.standard_population ?? '—'}</td>282 <td className="text-[12px] text-ink-3">{toDate(c.last_updated)?.toISOString().slice(0, 10) ?? '—'}</td>283 </tr>284 ))}285 </tbody>286 </table>287 </div>288 <ul className="mt-3 space-y-1.5 text-[13px]">289 {usedSources.map((s) => (290 <li key={s.slug} className="flex flex-wrap items-center gap-2">291 <Link className="ci-link" href={`/source/${s.slug}`}>292 {s.name}293 </Link>294 <Badge tone={s.license_status === 'approved' ? 'ok' : 'warn'}>license: {s.license_status}</Badge>295 {s.license ? <span className="text-[12px] text-ink-3">{s.license}</span> : null}296 </li>297 ))}298 </ul>299 <Note>300 {standardPop ? `Age-standardized rates use the ${standardPop}; they are not comparable with rates standardized to the World (Segi) population used by IARC. ` : ''}301 Global comparisons are not available: the IARC Global Cancer Observatory remains under license review and the SEER API awaits credentials (CLAUDE.md §10.3-10.4). Only geographies with licensed observations get a page; see <Link className="ci-link" href="/countries">/countries</Link>.302 </Note>303 <Freshness dataUpdatedAt={freshest} sourceVersion={coverage.length ? `${years[years.length - 1]}–${latestYear}` : null} extra={`${fmtInt(coverage.reduce((n, c) => n + c.n_years * c.n_cancers, 0))} observations approx. · ranks from snapshots geo=${scopeCode}`} />304 </Section>305 </article>306 );307}308309function headerLede(geo: { name: string; kind: string }) {310 return `Cancer incidence and mortality for ${geo.name} as published by the source registry: per site group, per year and sex, with age-standardized rates where the source provides them. Population statistics describe groups, never individuals.`;311}312313function toSeries(points: Awaited<ReturnType<typeof trendFor>>): TrendSeries[] {314 const map = new Map<string, TrendSeries>();315 for (const p of points) {316 if (!map.has(p.cancer_id)) map.set(p.cancer_id, { key: p.cancer_id, name: p.canonical_name, href: `/cancer/${p.slug}/statistics`, points: [], dashed: false });317 const s = map.get(p.cancer_id)!;318 s.points.push({ x: Number(p.year), y: Number(p.value) });319 if (p.estimate_type !== 'observed') s.dashed = true;320 }321 // Order series by latest value descending so the legend matches the table.322 return [...map.values()].sort((a, b) => (b.points.at(-1)?.y ?? 0) - (a.points.at(-1)?.y ?? 0));323}324325function TopTable({ t, year, sex, scopeCode, prov, geoSlug }: { t: TopCancersResult; year: number; sex: Sex; scopeCode: string; prov: Awaited<ReturnType<typeof loadProvenance>>; geoSlug: string }) {326 const label = EPI_METRIC_LABEL[t.metric] ?? t.metric;327 const first = t.rows[0];328 const ranked = t.rows.some((r) => r.rank != null);329 const scopeKey = t.year != null ? `geo=${scopeCode}|sex=${sex}|age=all|year=${t.year}|level=top` : null;330 return (331 <Section id={t.metric} kicker="Top cancers" title={`By ${label.toLowerCase()}`} description={t.year == null ? undefined : `${t.year}${t.year !== year ? ` — latest year available for this metric (${year} not yet published by the source)` : ''} · ${sex === 'all' ? 'both sexes' : humanize(sex)} · all ages · ${fmtInt(t.rows.length)} site groups${first?.standard_population ? ` · ${first.standard_population}` : ''}`}>332 {!first ? (333 <EmptyState compact>No {label.toLowerCase()} observation for this geography and sex.</EmptyState>334 ) : (335 <>336 <div className="ci-table-wrap">337 <table className="ci-table">338 <thead>339 <tr>340 <th className="num">#</th>341 <th>Cancer</th>342 <th className="num">343 {label} ({unitLabel(first.unit)})344 </th>345 <th className="num">95% CI</th>346 <th>Type</th>347 <th>Rank in scope</th>348 <th>Site definition</th>349 <th>Source</th>350 </tr>351 </thead>352 <tbody>353 {t.rows.map((r, i) => (354 <tr key={r.cancer_id}>355 <td className="num">{i + 1}</td>356 <td>357 <Link className="ci-link" href={`/cancer/${r.slug}`}>358 {r.canonical_name}359 </Link>360 <span className="ml-1.5 text-[11px] text-ink-3">{humanize(r.entity_type)}</span>361 </td>362 <td className="num font-medium">{fmtValue(r.value, r.unit)}</td>363 <td className="num text-ink-3">{r.lower_ci != null && r.upper_ci != null ? `${fmtValue(r.lower_ci, r.unit)}–${fmtValue(r.upper_ci, r.unit)}` : '—'}</td>364 <td>365 <Badge tone={r.estimate_type === 'observed' ? 'ok' : 'warn'}>{r.estimate_type}</Badge>366 </td>367 <td className="text-[12.5px]">368 {r.rank != null && r.rank_scope_key ? (369 <Link className="ci-link" href={`/rankings/${t.metric}?scope=${encodeURIComponent(r.rank_scope_key)}`} title={r.rank_scope_key}>370 #{r.rank} of {fmtInt(r.eligible_entities)}371 </Link>372 ) : (373 <span className="text-ink-4" title="No current ranking snapshot for this metric and scope">no snapshot</span>374 )}375 </td>376 <td className="max-w-[260px] text-[11.5px] text-ink-3">{r.site_definition ?? '—'}</td>377 <td>378 <span className="inline-flex gap-1">379 <SourceBadge p={toInfo(prov.get(r.provenance_id)) ?? { sourceSlug: r.source_slug, sourceName: r.source_name }} compact />380 <ClaimBadge kind="observed" />381 </span>382 </td>383 </tr>384 ))}385 </tbody>386 </table>387 </div>388 {/* div, not p: the SourceBadge popover holds a <dl>, which would close a <p> in the HTML parser (hydration mismatch). */}389 <div className="mt-2 flex flex-wrap items-center gap-1.5 text-[12px] text-ink-3">390 <SourceBadge p={toInfo(prov.get(first.provenance_id)) ?? { sourceSlug: first.source_slug, sourceName: first.source_name }} />391 {ranked && scopeKey ? (392 <span>393 Rank from snapshot <span className="ci-mono">{scopeKey}</span> ·{' '}394 <Link className="ci-link" href={`/rankings/${t.metric}?scope=${encodeURIComponent(scopeKey)}`}>395 full ranking and "Why this rank?"396 </Link>397 </span>398 ) : (399 <span>"#" is the position in this table; no ranking snapshot covers this scope yet.</span>400 )}401 {t.rows.every((r) => r.estimate_type === 'observed') ? <ConfidenceBadge level="HIGH" /> : <ConfidenceBadge level="MEDIUM" />}402 <Link className="ci-link" href={`/country/${geoSlug}?sex=${sex}&year=${t.year}#trend`}>403 trend ↓404 </Link>405 </div>406 <Freshness dataUpdatedAt={t.rows.map((r) => toDate(r.updated_at)).filter((d): d is Date => !!d).sort((a, b) => b.getTime() - a.getTime())[0] ?? null} sourceVersion={prov.get(first.provenance_id)?.dataset_version ?? null} />407 </>408 )}409 </Section>410 );411}412